#!/usr/bin/python2.4
# 
# Copyright 2008 Google Inc. All Rights Reserved.

import time
import pygooglechart
import datetime
import data_processor

"""
  Chartomatic helps manage intelligent charting. 
  It should handle all your chart needs and provide
  your customers with the most relevant charts for
  any given data set.

  Usage:
    chart_instance = chartomatic.Chart(title="Rohit's graphs")
    chart_instance.AddData([1,2,2,3],'revenue')
    chart_instance.AddData([20,40,30,10],'clicks')
    labels = ['1/1/2008','1/1/2009','1/1/2010','1/1/2011']
    chart_instance.AddLabels(labels,True)
    chart_instance.AddMarkerByLabel('clicks','1/1/2009',type='v',size=5,
                                    color="fffacd")
    url = chart_instance.GetUrl()
"""

__author__ = "rjenveja@gmail.com (Rohit Jenveja)"

class BadDataError(Exception): pass
class NotSupportedGraphType(Exception): pass
class BadDataFormat(Exception): pass
class BadChartError(Exception): pass

class Chart(object):

  DEFAULT_CHART_WIDTH = 400
  DEFAULT_CHART_HEIGHT = 200
  DEFAULT_ROUND_AMOUNT = 2

  def __init__(self, type='SimpleLineChart', width=DEFAULT_CHART_WIDTH,
               height=DEFAULT_CHART_HEIGHT, title='', 
               pygooglechartobj=None, chart_dict=None, labels=[],
               intelligent_labels=False):
    """
      Args: 
        type: (string) SimpleLineChart is the standard. You can view all
        of the different types of chart's at google's chart api documentation
        site.
        width: (int) the width of the graph
        height: (int) the height of the graph
        title: (string) the title as you want it to appear on the graph
        pygooglechartobj: if you have an existing pygooglechart that
        you'd like to pass in. 
        chartdict: if you have a dictionary that you'd like to pass in with 
        chart attributes/values.
        intelligent_labels: bool whether you'd like intelligent labelling.
      Returns: 
        None
      Throws: 
        None

    """
    if pygooglechartobj and chart_dict:
      raise BadDataFormat("You cannot pass in both a pygooglechart and chart_dict")
    self.data_points = 0 #list size which all data_sets and labels must be
    self.data_names = []
    self.pre_normalized_data = []
    self._labels = labels
    if isinstance(pygooglechartobj,pygooglechart.Chart):
      self.chart = pygooglechartobj
    elif isinstance(chart_dict,dict):    
      self.chart = self._DictToChart(chart_dict)
    else:
      self.chart = self._DictToChart({'type':type,
                                'width':width,'height':height,'title':title})
      #if any values were passed in, we want to alter the chart obj
    if title !='':
      self.chart.set_title(title)
    if height!=self.DEFAULT_CHART_HEIGHT:
      self.chart.height=height
    if width!=self.DEFAULT_CHART_WIDTH:
      self.chart.width=width
    if labels:
      self.AddLabels(labels,intelligent_labels)
    if type!='SimpleLineChart':
      self.chart.type=type

  def AddData(self, data, data_name):
    """
      Adds data to chart. If data_name already exists,
      then it replaces the appropriate data set.
        
      Args:
        data = [x,y]
        data_name: a way to reference this data set in the future.
        This is important to define so that you can add markers,
        or change the data prior to building the charts.
      Returns: 
        None
      Throws: 
        None
    """
    if not isinstance(data,(list,tuple)):
      raise BadDataError("%s was not a type list"%data_name)
    if not data:
      raise BadDataError("%s is an empty list"%data_name)
    if not self.data_points:
      self.data_points = len(data)
    if (len(data) != self.data_points):
      raise BadDataError("%s has invalid length. %s and data_points is %s and list is %s"
                          %(data_name,str(len(data)),str(self.data_points),
                            str(self.pre_normalized_data)))
    if data_name in self.data_names:
      index = self.data_names.index(data_name)
      self.pre_normalized_data[index] = data
    else: 
      self.pre_normalized_data.append(data)
      self.data_names.append(data_name) #keep track of the index.


  def AddLabels(self, labels, intelligent_labels=False):
    """
      Add labels to your graph.
      Args:
        label: a list of strings. If they aren't string, they
              will be casted into strings.
        intelligent_labels: whether or not you'd like the 
                          program to adjust labelling 
                          based on space restrictions.
      Returns: 
        None
      Throws: 
        None
    """
    if not labels:
      self._labels = [] #clear your labels
      return
    if not self.data_points:
      self.data_points=len(labels)
    if self.data_points!=len(labels):
      raise BadDataError("Length of the labels is incorrect")  
    if intelligent_labels:
      self._AddIntelligentLabels(labels)
    else:
      self._AddDumbLabels(labels)

  def GetUrl(self):
    """
    Returns a url of your graph.
    Args: 
      None
    Returns: 
      The url for the graph which has been created
    Throws: 
      None
    """
    self.SetAxis()
    self.chart.data = data_processor.NormalizeNestedListOfData(
                                          self.pre_normalized_data)
    return self.chart.get_url().replace(',','%2C') #pygooglechart

  def SetAxis(self):
    """
    Adds all axis labels
    Args: 
      None
    Returns:
      None
    Throws:
      None
    """
    if not self.chart.axis and self._labels:
      self.chart.set_axis_labels(pygooglechart.Axis.BOTTOM,self._labels)
      max_value_in_nested_list = round(max(max(self.pre_normalized_data)),2)
      min_value_in_nested_list = round(min(min(self.pre_normalized_data)),2)
      self.chart.set_axis_range(pygooglechart.Axis.LEFT,
                                      min_value_in_nested_list,
                                      max_value_in_nested_list)

  def DownloadGraph(self,path_to_file):
    """
      Saves a local copy of the graph if you'd like to have offline usage.
      
      Args:
        path_to_file: path to the file in which you want the graph
        image. example: /home/rohitj/graph.png
      Returns: 
        the path to the file.
      Throws: 
        None
    """
    
    self.SetAxis()
    self.chart.data = data_processor.NormalizeNestedListOfData(
                                        self.pre_normaized_data)
    return self.chart.download(path_to_file)

  @classmethod
  def _DictToChart(cls,d):
    """
    Takes a dictionary and transforms it to a pygooglechart object.
    Args:
      d: a dictionary with the following format:
        { "type": str,    # or any other supported type
          "width": int,
          "height": int,
          "legend": [str,str,...],   # one str per data set
          "colors": [str,str,...],   # one str per data set
          "title": str,
          
        }
    Returns:
      A pygooglechart instance.
    Throws:
      BadChartError if we can't parse the dict
    """
    if not d.has_key('type'):
      raise BadChartError("You must pass in a chart type.")
    height = d.get("height") or cls.DEFAULT_CHART_HEIGHT
    width = d.get("width") or cls.DEFAULT_CHART_WIDTH
    chart = getattr(pygooglechart,d.get('type'))(width,height)
    if d.has_key("legend"):
      chart.set_legend(d.get("legend"))
    if d.has_key("colors"):
      chart.set_colours(d.get('colors'))
    if d.has_key("title"):
      chart.set_title(d.get('title'))
    return chart
    
  def DeterminePadding(self,labels):
    """ 
      Examines the list of labels and determines the maximum number of
      charachters likely needed to accomodate the labels comfortably 
      in the graph
      Args:
        labels: a list of labels
      Returns: 
        (int) the padding needed.
      Throws: 
        None
    """
    labels = map(str,labels)
    max_number_of_charachters = max(map(len,labels))
    return max_number_of_charachters*7
 
  def _AddDumbLabels(self, labels):
    """
    Adds labels with very little intelligence.
    Args:
      labels: a list of your desired labels
    Returns: 
      None
    Throws: 
      None
    """
    labels = map(str,labels)
    if labels:
      for i in range(1,len(labels)-1):
        if i != len(labels)/2:
          labels[i] = ""
      self._labels = labels

  def _AddIntelligentLabels(self, labels, date_format='%m/%d/%Y'):
    """
    Tries to add intelligence to the labelling process.
    Compares the width of the graph to the number of
    labels being passed in. It may modify the number of labels.
    Args:
      labels: list of labels to be passed in
      date_format: a format for getting date information
    Returns: 
      None
    Throws:
      Bad Data Error if the labels aren't a list.
    """
    assert isinstance(labels,list),"labels must be passed in as a list"
    padding = self.DeterminePadding(labels)
    max_labels = self.chart.width/padding


    if max_labels > self.data_points and self.data_points:
      max_labels = self.data_points
    if not self.data_points:
      self.data_points = max_labels
    if self.CheckIfDateFormat(labels,date_format):
      labels = self.SmartDate(labels,date_format)
    else:
      increment = len(labels)/max_labels
      for i in range(0,len(labels)):
        if i%increment:
          self._labels.append(labels[i])

  def CheckIfDateFormat(self, labels, date_format):
    """
    Checks if labels are in date_format. Bool response
    Args:
      labels: list of strings, or list of datetimes
      date_format: the format of the string dates we should be looking for
    Returns:
      Bool (True or False)
    Throws:
      A ValueError if its not in the correct format.
    """
    if not labels:
      return False
    for label in labels:
      if not isinstance(label,datetime.datetime) and isinstance(label,str):
        try:
          EpochSeconds = time.mktime(time.strptime(label,date_format))
        except ValueError:
          return False
    return True

  def SmartDate(self, labels, date_format):
    """
    Args:
      labels: list of the labels to be put on graph
      format: format of the dates
    Returns: 
      labels (list) with the labels to be used
    Throws:
      BadDataFormat if its not in date_format.
      BadDataError if the width is too small to accomodate any information.
    """
    dates = []
    padding = self.DeterminePadding(labels)
    for label in labels:
      if isinstance(label,str):
        EpochSeconds = time.mktime(time.strptime(label,date_format))
        d = datetime.date.fromtimestamp(EpochSeconds)
      else: 
        d = datetime.date(label.year,label.month,label.day)
      dates.append(d)
      desired_amount_of_labels = self.chart.width/padding
      possible_graph_time_units = ["year","quarter","month","week",
                                 "day"]
    end_date = dates[-1]
    start_date = dates[0]
    delta = end_date-start_date
    years = delta.days/365
    quarters = delta.days/90 
    months = delta.days/30
    weeks = delta.days/7
    days = delta.days
    intervals = [years,quarters,months,weeks,days]
    number_of_splits = self.CompareListValuesToValue(intervals,
                                      desired_amount_of_labels)
    labels = [start_date.strftime(date_format)]
    if number_of_splits == 'None':
      raise BadDataError("The chart is only %s in width, which is not"
                         "enough for 2 labels. make your graph larger.")
    for i in range(1,number_of_splits+1):
      labels.append((start_date+((delta)/
                     number_of_splits)*i).strftime(date_format))
    self._labels = labels

  def CompareListValuesToValue(self,list_of_values,value,closest_item='None',
                               closest_value = 'None'):
    """
    Iterates through a list and finds the value closest to "value".
    Args:
      list: a python list to pass in 
      value: an integer that we are trying to get close to
      closest_item: if any close item thus far
      closest_value: the delta of the value - item
    Returns: 
      closest value in a list to an int.
    Throws: 
      None
    """
    assert len(list_of_values),"This list is of length zero"
    diffs = []
    for item in list_of_values:
      #we need item > 0, because a zero in time is meaningless.
      if 0 <= (value - item) < closest_value and item > 0:
        closest_value = value-item
        closest_item = item
    return closest_item

  def AddMarker(self, data_name, point, type='v', color='000000' ,size=10):
    """
    Add a Marker to the graph.
    Args:
      data_name: the line on which we're drawing the marker
      point: where on line does it go. floating point if not horizontal
      type:
	      a represents an arrow.
	      c represents a cross.
	      d represents a diamond.
	      o represents a circle.
	      s represents a square.
	      t represents text.
	      v represents a vertical line from the x-axis to the data point.
	      V represents a vertical line to the top of the chart.
	      h represents a horizontal line across the chart.
	      x represents an x shape.
     Returns:
      None
     Throws:
      None
    """
    self.chart.add_marker(data_name,point,type,color,size)
    
  def AddMarkerByLabel(self,data_name,x_label,type='v',color='000000',
                       size=10,strict=False):
    """
    Adds a marker to your graph from a label.
    Args:
     data_name: the relevant line which we'll be marking on the chart.
     x_label: the label which we intend to target. For example,
     if ['1/1/2006','1/2/2006'] x_label would be one of the two string items
     in the list.
     type: see _AddMarker doc string.
     color: rgb value
     size: number of pixels for label
     strict: if you'd like to be notified of an error
    Returns: 
      None
    Throws: 
      None
    """
    try:
      point = self._labels.index(x_label)
      self.AddMarker(self.data_names.index(data_name),point,type,color,size)
    except ValueError:
      if strict:
        raise ValueError("No label %s found to add marker." % x_label)
	
  def CreateMarkersFromDictionary(self, markers):
    """
    Takes values from a dictionary and creates a new marker on the graph
    Args:
      markers: dictionary from which markers are generated
    Returns: 
      None
    Throws: 
      None
    """
    for line in markers:
      label = markers[line]['x_label']
      type = markers[line]['type']
      color = markers[line]['color']
      size = markers[line]['size']
      self.AddMarkerByLabel(line,label,type,color,size)

  def _GetTitle(self):
    return self.chart.title

  def _SetTitle(self, title):
    self.chart.title = title

  def _SetSize(self, size):
    """
    Takes a list of size 2: [x,y] and sets width, height.
    Args:
      size: list [width,height]
    Returns: 
      None
    Throws:
      None
    """
    if len(size) != 2:
      raise BadDataError("Size requires a [x,y] list where x and y.")
    self.chart.width = size[0]
    self.chart.height = size[1]
	
  def _GetSize(self):
    return [self.chart.width,self.chart.height]

  def _SetXSize(self, pixels):
    self.chart.width = pixels
    
  def _SetYSize(self, pixels):
    self.chart.height = pixels
  
  def _GetLabels(self):
    return self._labels
        
  def _SetLabels(self,labels):
    self._AddDumbLabels(labels)
    
  def _GetLegend(self):
    return self.chart.legend

  def _SetLegend(self, legend):
    if isinstance(legend,(list,tuple)):
      self.chart.legend = legend
    else:
      self.chart.legend = [legend,]
  
  def _GetColors(self):
    return self.chart.colours
    
  def _SetColors(self,colors):
    #colors must be a list or tuple
    if isinstance(colors,list) or isinstance(colors,tuple):
      self.chart.colours = colors
    else:
      raise BadDataError("Colors must be a list or tuple")
 
  size = property(_GetSize,_SetSize)
  labels = property(_GetLabels,_SetLabels)
  colors = property(_GetColors,_SetColors)
  title = property(_GetTitle,_SetTitle)
  legend = property(_GetLegend,_SetLegend)
